Skip to content

[reward, trainer] feat: add managed multi-reward deployments - #524

Merged
knlnguyen1802 merged 21 commits into
verl-project:mainfrom
Sky-Trigger:MultiRewardDeployments
Sep 14, 2026
Merged

knlnguyen1802 merged 21 commits into
verl-project:mainfrom
Sky-Trigger:MultiRewardDeployments

Conversation

@Sky-Trigger

@Sky-Trigger Sky-Trigger commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Related to #432.

This PR adds opt-in named reward models under reward.models. A training job
can use multiple model-backed rewards, give each model an explicit lifecycle
and resource allocation, and keep model inference separate from reward
calculation.

Two backends are supported:

  • engine wraps the pinned upstream verl.RewardModelManager. The currently
    supported and validated named-engine path is vLLM.
  • native loads a user-supplied model class inside accelerator-bound Ray
    reward workers for checkpoints that are not supported by the serving engine.

Each configured reward.reward_functions.<term>.path/name function owns input
interpretation and final score semantics. Engine models expose router/model
arguments; native models expose a reward_model inference handle.

Native backend contract

The native backend is deliberately small:

  • it is replica-only in this PR: each placement.devices entry creates one
    complete model replica;
  • the user-supplied executor.model class owns model construction and
    inference;
  • the framework owns placement, lifecycle, and request dispatch;
  • infer(*args, **kwargs) is an intentionally untyped internal contract
    between the native model adapter and its configured reward function;
  • synchronous and asynchronous infer() and optional close() methods are
    supported.

Native TP/PP/FSDP is not implemented. A future sharded-native design needs an
RFC-level replica-group contract; a flat device list cannot distinguish
complete replicas from ranks within one sharded replica.

Resource ownership and placement

The trainer still selects one parent resource pool:

  • reward.reward_model.enable_resource_pool=false selects global_pool;
  • reward.reward_model.enable_resource_pool=true selects the dedicated
    reward_pool.

Engine allocations consume a contiguous prefix of that parent pool in model
configuration order, with one upstream SubRayResourcePool per engine model.

Each native model receives an independent indexed allocation. Its
placement.devices values are global logical bundle indices within the
selected parent pool. They are not physical CUDA/NPU IDs and are not
tensor-parallel ranks. Indices may be non-contiguous, but must be in range and
cannot overlap another native model or the engine prefix.

For example:

selected parent pool
├── bundles 0-3  -> engine model A
├── bundles 4,6  -> native model B, two complete replicas
├── bundles 5,7  -> native model C, two complete replicas
└── other bundles -> unused by named rewards

When the global pool is selected, bundles unused by named rewards may still be
used by actor/rollout workers.

Reward manager behavior

Named-model aggregation currently supports the visual sample contract only.
Jobs must explicitly select:

reward:
  reward_manager:
    name: MultiVisualRewardManager

The framework validates this requirement and does not silently rewrite a
user-provided reward manager. A modality-neutral manager and Audio/Visual
manager consolidation are follow-up work.

Lifecycle and async API

Engine and native models share one user-facing offload option:

  • offload: true wakes before scoring and sleeps afterward;
  • offload: false keeps the model resident.

Scoring is bracketed by wake/score/sleep with cleanup in finally. Independent
model groups execute concurrently inside one scoring call. A manager-level
single-flight lock serializes overlapping calls to
async_compute_rm_score(), preventing one request from sleeping models while
another request is entering Ray dispatch.

compute_rm_score() remains the synchronous compatibility wrapper used by
current trainers.

Compatibility

  • Existing jobs without reward.models keep the legacy single-model path.
  • reward.reward_model.enable=true cannot be combined with named models.
  • Existing custom reward functions and legacy accelerator workers remain
    available when named models are absent.
  • The PR does not automatically migrate or rewrite legacy configurations.

The extension and migration guide is
docs/algo/named_reward_models.md.

Validation

Validated in the verl-omni:gpu container:

python -m pytest -q \
  tests/reward_loop/test_audio_reward_manager_on_cpu.py \
  tests/reward_loop/test_multi_reward_manager_on_cpu.py \
  tests/reward_loop/test_named_reward_models_on_cpu.py \
  tests/reward_loop/test_reward_loop_manager_on_cpu.py \
  tests/trainer/diffusion/test_v1_colocate_reward_on_cpu.py
# 116 passed

Also passed:

  • Ruff and format checks;
  • dynamic import of the relocated pooling worker extension;
  • py_compile for changed Python modules;
  • reward configuration documentation test;
  • docs timestamp and imported-doc validation;
  • exported API docstring check for verl_omni;
  • shell syntax checks for the native and engine/native NPU launchers.

The NPU engine/native E2E recipe is
tests/special_e2e/run_qwen_image_edit_lora_v1_npu_engine_native.sh.
It was manually rerun successfully on a 16-NPU setup after fixing duplicate
rollout sleep in the synchronous compatibility path. This manual result is
reported separately from CI.

Current limitations and follow-up work

  • Named-model aggregation currently uses MultiVisualRewardManager.
  • Native execution is accelerator-only and replica-only.
  • Native FSDP requires a separate RFC decision and replica-group design.
  • Dynamic load balancing and work stealing are not implemented.
  • Named models do not participate in streaming reward computation.
  • vLLM-Omni reward serving is not implemented.

@Sky-Trigger

Copy link
Copy Markdown
Collaborator Author

@chenyingshu PTAL. I've been working on this recently, but it still needs some time to be further refined.

@Sky-Trigger Sky-Trigger added the ready-for-ci read for running CI label Sep 4, 2026
@Sky-Trigger
Sky-Trigger force-pushed the MultiRewardDeployments branch from 0784651 to 5b5b8a5 Compare September 4, 2026 07:35
@github-actions github-actions Bot removed the ready-for-ci read for running CI label Sep 4, 2026
@Sky-Trigger Sky-Trigger added the ready-for-ci read for running CI label Sep 4, 2026
@github-actions github-actions Bot removed the ready-for-ci read for running CI label Sep 7, 2026
@Sky-Trigger Sky-Trigger added the ready-for-ci read for running CI label Sep 7, 2026
@github-actions github-actions Bot removed the ready-for-ci read for running CI label Sep 7, 2026
@Sky-Trigger Sky-Trigger added the ready-for-ci read for running CI label Sep 7, 2026
@github-actions github-actions Bot removed the ready-for-ci read for running CI label Sep 7, 2026
@Sky-Trigger
Sky-Trigger force-pushed the MultiRewardDeployments branch 2 times, most recently from b0407c9 to 63e0760 Compare September 7, 2026 04:49
@Sky-Trigger Sky-Trigger added ready-for-ci read for running CI and removed ready-for-ci read for running CI labels Sep 7, 2026
@Sky-Trigger Sky-Trigger changed the title [draft, reward, trainer] feat: add managed multi-reward deployments [reward, trainer] feat: add managed multi-reward deployments Sep 7, 2026
@Sky-Trigger Sky-Trigger added the ready-for-ci read for running CI label Sep 7, 2026
@Sky-Trigger Sky-Trigger added ready-for-ci read for running CI and removed ready-for-ci read for running CI labels Sep 7, 2026
@Sky-Trigger
Sky-Trigger force-pushed the MultiRewardDeployments branch 4 times, most recently from f9b3bcd to 0c744b3 Compare September 9, 2026 07:33
@Sky-Trigger Sky-Trigger added the ready-for-ci read for running CI label Sep 9, 2026
@Sky-Trigger
Sky-Trigger force-pushed the MultiRewardDeployments branch from 17ae9d9 to 5b83d29 Compare September 9, 2026 08:41
@github-actions github-actions Bot removed the ready-for-ci read for running CI label Sep 9, 2026
@Sky-Trigger
Sky-Trigger force-pushed the MultiRewardDeployments branch from 5b83d29 to 96ec737 Compare September 9, 2026 08:50
@Sky-Trigger
Sky-Trigger marked this pull request as ready for review September 9, 2026 08:53
@Sky-Trigger
Sky-Trigger requested a review from zhtmike September 14, 2026 01:52
@Sky-Trigger Sky-Trigger added the ready-for-ci read for running CI label Sep 14, 2026
Comment thread verl_omni/trainer/config/reward/reward.yaml Outdated
Signed-off-by: Trigger <Meng.Bo.Wang@outlook.com>
@Sky-Trigger
Sky-Trigger requested a review from cr-gao as a code owner September 14, 2026 02:09
@github-actions github-actions Bot removed the ready-for-ci read for running CI label Sep 14, 2026
@Sky-Trigger Sky-Trigger added the ready-for-ci read for running CI label Sep 14, 2026
@zhtmike

zhtmike commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Verdict: the engine path is upstream-aligned, the async API shape is right, and the CPU test suite is genuinely good. Holding merge on three design items (RFC sync for the native backend, manager coupling, native placement addressing) — all are cheap to fix now and expensive to fix after configs pile up on top. The async ask from the earlier thread landed; one concurrency gap below.


Blocking — design

1. Native backend: right call, wrong paper trail — amend RFC #432 and pin the contract now

reward_model_executor.py:65, reward_model.py:227

Native is the right call for this repo: PickScore, HPSv3, CLIP scorers and most small non-LLM reward checkpoints will never be a vLLM/SGLang serving priority. But RFC #432 — the governing design doc this PR implements — says the opposite in three places:

  • "The adapter does not implement model execution, batching, scheduling, parameter sharding, or collectives."
  • Non-goals: "It does not add a new inference runtime."
  • Non-goals: "It does not move model weights into TaskRunner or RewardLoopWorker."

NativeRewardExecutor does all three (weights in reward-loop workers, manager-side batching via even split + padding, framework-side placement). The PR description doesn't mention this divergence either.

The boundary question needs answering deliberately, because the follow-up plan (CPU-native, FSDP) walks further in. Upstream verl's history is the cautionary tale: it deprecated and deleted its framework-hosted FSDP/Megatron reward-model workers (cb01f10ba, #3352; removed in #5255) because sharded model execution inside the trainer was the maintenance cost center — that's the subsystem being incrementally rebuilt if FSDP lands. The property that keeps native healthy is what this PR already does: replica-only, user owns the model class (executor.model), framework owns only placement/lifecycle/dispatch.

Ask: update RFC #432 in this PR to make native a first-class backend, with the contract written down: (a) replica-only — no framework-side TP/PP/FSDP, models too big for one device belong in the engine backend; (b) the user-supplied executor class owns inference; (c) infer(*args, **kwargs) is the deliberately untyped adapter-internal contract (fine — but say so, it's the load-bearing interface every native adapter builds against). If FSDP-native is genuinely wanted later, that's an RFC decision, not a follow-up PR.

2. Named models are locked to the visual manager, via a silent config rewrite

reward_loop.py:129

with open_dict(config.reward.reward_manager):
    config.reward.reward_manager.name = "MultiVisualRewardManager"

Two problems:

The rewrite breaks non-default configs. Upstream resolves managers via resolve_reward_manager_cls on source (register | importlib) + module.path + name. Mutating only .name works solely for verl-omni's exact default: with source: register it raises "Unknown reward manager" (nothing registers that name), and with a user's module.path it tries to load MultiVisualRewardManager from the user's module. The PR body promises "does not silently rewrite legacy configurations" — this is a silent rewrite of the new configuration path.

The lock-in blocks half this repo's use cases. reward.models is presented as general, but scoring flows through MultiVisualRewardManager.run_single, which consumes the visual sample contract (solution_image …). Audio/omni reward models can never use named models as-is. The docs admit it ("Named-model aggregation currently uses the visual reward manager contract") — that's a limitation only if it stays.

The fix is cheap because the abstraction already exists: VisualRewardManager already extends upstream's modality-neutral RewardManagerBase, and upstream's managers (naive, dapo, gdpo, limited, remote) all register under modality-neutral names via @register.

Ask: (a) don't rewrite — validate: error if the configured manager is user-set or not multi-capable; (b) introduce a modality-neutral MultiRewardManager on RewardManagerBase holding the executor dispatch (set_reward_executors) + weighted sum + required policy, with visual sample extraction in a MultiVisualRewardManager(MultiRewardManager) subclass. Then reward_loop.py:129 disappears.

3. placement.devices addressing is order-dependent and unrooted

reward_model.py:140-172, reward.yaml

Native indices address an internal native subpool that is allocated after all engine allocations, in engine config order, and sized max(index)+1. Consequences:

  • Adding or reordering one engine model silently remaps what every native index means.
  • Sparse indices strand bundles ([0,1] + [4,5] → indices 2,3 allocated, unused; plus the leftover tail pool).
  • The docs need repeated disclaimers ("not physical CUDA/NPU IDs, not TP ranks") — when an abstraction needs three paragraphs of disclaimers, that's the design asking to be changed.

Ask: one subpool per native model (exactly what engine models already get), so devices are per-model, stable, and dense; the shared-native-subpool becomes an implementation detail that can vanish. Direction check for the long term: upstream async trainers are moving toward elastic accelerator allocation (dynamic resource scheduling #6556, idle-GPU lending #7373) — keep the static split internal so a future dynamic allocator can replace it without breaking configs.


Should fix

4. async_compute_rm_score is not single-flight

reward_loop.py (async_compute_rm_score), reward_model_executor.py:114

The worker-side drain (lock + inflight + idle event, with the re-check loop in sleep()) is correctly built. But idle-tracking can only see requests that have landed on the worker. With offload: true: batch A finishes and enters its finallysleep() observes idle while batch B's compute_score_batch RPCs are still in Ray dispatch → A destroys the model → B's infers raise "not awake". Trainers call it serially today, so it's latent — but publishing an async API is an invitation to overlap.

Ask: a manager-level asyncio.Lock around the wake/score/sleep bracket (two lines), which also matches RFC #432's "sequential execution on shared resources" stance until phase-3 admission fencing.

5. Config schemas and trainer predicates live in the wrong layer

reward_loop/reward_model_config.py (392 lines)

Upstream keeps reward schemas in verl/workers/config/reward.py (RewardModelConfig at :82, post-#5255 refactor); verl-omni already mirrors that layout with verl_omni/workers/config/{diffusion,omni}. And the trainer predicates (reward_is_enabled, reward_role_required, reward_pool_is_separate, streaming_reward_enabled) are trainer concerns — upstream's equivalent need_reward_model lives in verl/trainer/ppo/utils.py, not in the reward loop. Right now five trainer modules import from verl_omni.reward_loop.reward_model_config, inverting the layering (trainers reaching into an experimental reward-loop package).

Ask: schemas → verl_omni/workers/config/reward.py; predicates → trainer utils (or the config module's public API), so trainer imports stop crossing into reward_loop.

6. Pooling worker extension is misplaced and untracked

reward_loop/vllm_worker.py:20

Every vLLM colocate extension in verl-omni lives in verl_omni/workers/rollout/vllm_rollout/vLLMOmniColocateWorkerExtension (utils.py:35), NPU variant (npu_utils.py:123). A module named vllm_worker.py inside reward_loop/ misleads on both counts (not a reward-loop worker; not where vLLM extensions live). The PR body admits it's temporary ("can be removed after upstream verl handles pooling models generically") but the code carries no TODO(owner) + upstream issue, per repo policy for temporary workarounds. Note upstream already supports pooling RMs elsewhere (/classify determinism, #7027) — the generative assumption in the colocated extension is arguably an upstream bug worth filing rather than shimming forever.

Ask: move next to the other extensions, add TODO(owner) + upstream issue link, and prefer fixing upstream.

7. Dead code in the manager

  • reward_loop.py:87OmniRewardLoopWorker.close() is never invoked: upstream RewardLoopWorker has no shutdown contract and no trainer path calls it. Resident native models (offload: false) therefore hold accelerator memory until process exit. Wire it into trainer shutdown or drop it.
  • reward_loop.py:122 — the second reward.reward_model.enable raise is unreachable (reward_model.py:52 already raised for models+enable, and accelerator-workers+enable raises on the line above). Drop it.

Nits

  • reward_loop.py _resolve_result/_gather_results double-wrap Ray refs — ObjectRefs are directly awaitable inside asyncio.gather; one layer is enough.
  • MultiRewardModelManager exposes internals (native_device_assignments, native_resource_pool, bind_native_workers) for OmniRewardLoopManager to consume — friend-class coupling across modules. A single create_native_worker_group(name, factory) hook would tighten the surface.
  • resolved_offload accepting rollout.free_cache_engine/enable_sleep_mode as aliases gives two spellings for one semantic — your own cleanup plan covers this; make sure it survives the rebase.
  • NativeManagedRewardModel._resident is controller-side bookkeeping of worker-side state — fine while these Ray actors don't restart, worth a comment saying so.

PR hygiene

  • PR body is stale vs the code: describes reward.deployments while the shipped config is reward.models; links the guide at a fork path (docs/algo/managed_reward_deployments.md) while the real file is docs/algo/named_reward_models.md.
  • No test-run evidence in the body — per contribution policy (AI-assisted PRs) add the CPU test command + results and the NPU e2e status.

What landed well (for calibration)

  • required/optional failure policy with required=false default preserves legacy zero-contribution semantics — the test migration is honest about the behavior change.
  • Engine deployments wrapping verl.RewardModelManager per model is RFC phase 1 exactly as written; ManagedRewardModel's async wake_up/sleep ABC is the thin backend boundary the RFC asked for.
  • Async discipline beats upstream: try/finally sleep (upstream skips sleep on failure), sleep() gathers with return_exceptions=True and raises first, reverse-order sleep, sync entry raises inside a running loop instead of blocking it.
  • The CPU suite covers schema validation, pool splitting/overcommit, lifecycle delegation, executor contracts, and aggregation failure modes — this is the right test strategy for infra that can't run on CI GPUs.

AI assistance (ZCode) was used for this review.

Signed-off-by: Trigger <Meng.Bo.Wang@outlook.com>
Signed-off-by: Trigger <Meng.Bo.Wang@outlook.com>
Signed-off-by: Trigger <Meng.Bo.Wang@outlook.com>
@Sky-Trigger

Copy link
Copy Markdown
Collaborator Author

Thanks for the detailed follow-up review. I addressed the items that belong in this PR and recorded the larger migrations separately in #432.

Review item Status Resolution
Native execution contract Fixed Documented replica, ownership, inference, scoring, placement, and current parallelism boundaries in the PR and #432
Silent reward-manager rewrite Fixed Removed it; named models now explicitly validate that the current manager is MultiVisualRewardManager
Generic MultiRewardManager Deferred Tracked in #432 as a separate migration PR, followed by Audio/Visual manager consolidation
Native placement / sparse indices Fixed Each native deployment now selects independent global logical bundle indices in the parent pool; indices may be non-contiguous, do not allocate max(index)+1, and overlaps/out-of-range values are rejected
Manager-level async single-flight Fixed Added a manager lock around the complete wake/score/sleep lifecycle
Config layering Fixed Moved reward-model configuration under verl_omni/workers/config/reward.py
Pooling worker extension Fixed Moved it beside rollout worker extensions at verl_omni/workers/rollout/vllm_rollout/pooling_utils.py and added an owner TODO. Upstream verl PR #7027 is the related pooling-RM context; I could not find a dedicated upstream tracking issue
Dead shutdown path Fixed Removed the uncalled native executor shutdown helpers
Duplicate validation Fixed Removed the unreachable second reward.reward_model.enable check
Ray result awaiting Fixed Removed redundant result helpers and await asyncio.gather() directly
Lifecycle aliases Fixed Named engine models now expose only offload; legacy rollout sleep flags are internal compatibility wiring and conflicting per-model aliases fail fast
Native _resident bookkeeping Clarified Documented that it is controller-side lifecycle state; actor-restart recovery is outside the current native contract
Manager resource helper coupling Deferred Kept the already NPU-validated upstream resource-manager surface stable; a public helper can be introduced with the broader manager/resource API work

I also fixed a regression found by the manual NPU run: synchronous scoring could call rollout sleep twice. The compatibility wrapper now delegates lifecycle ownership only to the async path.

Validation for the current head:

  • 116 focused CPU tests passed in the verl-omni:gpu container;
  • Ruff lint and format checks passed;
  • the relocated pooling extension was dynamically imported successfully;
  • the engine/native PickScore recipe was manually rerun successfully on the 16-NPU setup.

Deferred follow-up work is tracked in the table on #432: unified MultiRewardManager, Audio/Visual manager consolidation, legacy path deprecation after model migrations, CPU-native execution, native FSDP, vLLM-Omni serving, dynamic load balancing, and full async/streaming integration.

Comment thread verl_omni/workers/rollout/vllm_rollout/pooling_utils.py Outdated
Signed-off-by: Trigger <129651635+Sky-Trigger@users.noreply.github.com>
@Sky-Trigger Sky-Trigger added the ready-for-ci read for running CI label Sep 14, 2026
Signed-off-by: Trigger <129651635+Sky-Trigger@users.noreply.github.com>
@github-actions github-actions Bot removed the ready-for-ci read for running CI label Sep 14, 2026
Comment thread docs/algo/named_reward_models.md Outdated
@Sky-Trigger Sky-Trigger added the ready-for-ci read for running CI label Sep 14, 2026
Signed-off-by: Trigger <Meng.Bo.Wang@outlook.com>
@github-actions github-actions Bot removed the ready-for-ci read for running CI label Sep 14, 2026
@Sky-Trigger Sky-Trigger added the ready-for-ci read for running CI label Sep 14, 2026

@zhtmike zhtmike left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good for me, wait CI pass

@Sky-Trigger

Copy link
Copy Markdown
Collaborator Author

@zhtmike CI passed

@Sky-Trigger
Sky-Trigger requested a review from zhtmike September 14, 2026 11:06
@knlnguyen1802
knlnguyen1802 merged commit d17ff5d into verl-project:main Sep 14, 2026
17 of 18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-ci read for running CI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants